// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io' as io;

import 'package:file/file.dart';
import 'package:http/http.dart' as http;
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import 'package:yaml_edit/yaml_edit.dart';

import 'common/core.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/pub_utils.dart';
import 'common/pub_version_finder.dart';
import 'common/repository_package.dart';

const int _exitIncorrectTargetDependency = 3;
const int _exitNoTargetVersion = 4;
const int _exitInvalidTargetVersion = 5;

/// A command to update a dependency in packages.
///
/// This is intended to expand over time to support any sort of dependency that
/// packages use, including pub packages and native dependencies, and should
/// include any tasks related to the dependency (e.g., regenerating files when
/// updating a dependency that is responsible for code generation).
class UpdateDependencyCommand extends PackageLoopingCommand {
  /// Creates an instance of the version check command.
  UpdateDependencyCommand(
    super.packagesDir, {
    super.processRunner,
    super.gitDir,
    http.Client? httpClient,
  }) : _pubVersionFinder = PubVersionFinder(
         httpClient: httpClient ?? http.Client(),
       ) {
    argParser.addOption(_pubPackageFlag, help: 'A pub package to update.');
    argParser.addOption(
      _androidDependency,
      help: 'An Android dependency to update.',
      allowed: <String>[
        _AndroidDependencyType.gradle,
        _AndroidDependencyType.androidGradlePlugin,
        _AndroidDependencyType.kotlinGradlePlugin,
        _AndroidDependencyType.compileSdk,
        _AndroidDependencyType.compileSdkForExamples,
      ],
      allowedHelp: <String, String>{
        _AndroidDependencyType.gradle:
            'Updates Gradle version used in plugin example apps.',
        _AndroidDependencyType.androidGradlePlugin:
            'Updates AGP version used in plugin example apps.',
        _AndroidDependencyType.kotlinGradlePlugin:
            'Updates KGP version used in plugin example apps.',
        _AndroidDependencyType.compileSdk:
            'Updates compileSdk version used to compile plugins.',
        _AndroidDependencyType.compileSdkForExamples:
            'Updates compileSdk version used to compile plugin examples.',
      },
    );
    argParser.addOption(
      _versionFlag,
      help:
          'The version to update to.\n\n'
          '- For pub, defaults to the latest published version if not '
          'provided. This can be any constraint that pubspec.yaml allows; a '
          'specific version will be treated as the exact version for '
          'dependencies that are alread pinned, or a ^ range for those that '
          'are unpinned.\n'
          '- For Android dependencies, a version must be provided.',
    );
  }

  static const String _pubPackageFlag = 'pub-package';
  static const String _androidDependency = 'android-dependency';
  static const String _versionFlag = 'version';

  final PubVersionFinder _pubVersionFinder;

  late final String? _targetPubPackage;
  late final String? _targetAndroidDependency;
  late final String _targetVersion;

  @override
  final String name = 'update-dependency';

  @override
  final String description = 'Updates a dependency in a package.';

  @override
  bool get hasLongOutput => false;

  @override
  PackageLoopingType get packageLoopingType =>
      PackageLoopingType.includeAllSubpackages;

  @override
  Future<void> initializeRun() async {
    const targetFlags = <String>{_pubPackageFlag, _androidDependency};
    final Set<String> passedTargetFlags = targetFlags
        .where((String flag) => argResults![flag] != null)
        .toSet();
    if (passedTargetFlags.length != 1) {
      printError(
        'Exactly one of the target flags must be provided: (${targetFlags.join(', ')})',
      );
      throw ToolExit(_exitIncorrectTargetDependency);
    }

    // Setup for updating pub dependency.
    _targetPubPackage = getNullableStringArg(_pubPackageFlag);
    if (_targetPubPackage != null) {
      final String? version = getNullableStringArg(_versionFlag);
      if (version == null) {
        final PubVersionFinderResponse response = await _pubVersionFinder
            .getPackageVersion(packageName: _targetPubPackage);
        switch (response.result) {
          case PubVersionFinderResult.success:
            _targetVersion = response.versions.first.toString();
          case PubVersionFinderResult.fail:
            printError('''
Error fetching $_targetPubPackage version from pub: ${response.httpResponse.statusCode}:
${response.httpResponse.body}
''');
            throw ToolExit(_exitNoTargetVersion);
          case PubVersionFinderResult.noPackageFound:
            printError('$_targetPubPackage does not exist on pub');
            throw ToolExit(_exitNoTargetVersion);
        }
      } else {
        _targetVersion = version;
        return;
      }
    }

    // Setup for updating Android dependency.
    _targetAndroidDependency = getNullableStringArg(_androidDependency);
    if (_targetAndroidDependency != null) {
      final String? version = getNullableStringArg(_versionFlag);
      if (version == null) {
        printError('A version must be provided to update this dependency.');
        throw ToolExit(_exitNoTargetVersion);
      } else if (_targetAndroidDependency == _AndroidDependencyType.gradle ||
          _targetAndroidDependency ==
              _AndroidDependencyType.androidGradlePlugin) {
        final validGradleAGPVersionPattern = RegExp(
          r'^\d{1,2}\.\d{1,2}(?:\.\d)?$',
        );
        final isValidGradleAGPVersion =
            validGradleAGPVersionPattern.stringMatch(version) == version;
        if (!isValidGradleAGPVersion) {
          printError('''
A version with a valid format (maximum 2-3 numbers separated by 1-2 periods) must be provided.
            1. The first number must have one or two digits
            2. The second number must have one or two digits
            3. If present, the third number must have a single digit''');
          throw ToolExit(_exitInvalidTargetVersion);
        }
      } else if (_targetAndroidDependency ==
          _AndroidDependencyType.kotlinGradlePlugin) {
        final validKgpVersionPattern = RegExp(r'^\d\.\d\.\d{1,2}$');
        final isValidKgpVersion =
            validKgpVersionPattern.stringMatch(version) == version;
        if (!isValidKgpVersion) {
          printError('''
A version with a valid format (3 numbers separated by 2 periods) must be provided.
            1. The first number must have one digit
            2. The second number must have one digit
            3. The third number must have one or two digits''');
          throw ToolExit(_exitInvalidTargetVersion);
        }
      } else if (_targetAndroidDependency ==
              _AndroidDependencyType.compileSdk ||
          _targetAndroidDependency ==
              _AndroidDependencyType.compileSdkForExamples) {
        final validSdkVersion = RegExp(r'^\d{1,2}$');
        final isValidSdkVersion =
            validSdkVersion.stringMatch(version) == version;
        if (!isValidSdkVersion) {
          printError(
            'A valid Android SDK version number (1-2 digit numbers) must be provided.',
          );
          throw ToolExit(_exitInvalidTargetVersion);
        }
      } else {
        // TODO(camsim99): Add other supported Android dependencies like the min/target Android SDK and AGP.
        printError(
          'Target Android dependency $_targetAndroidDependency is unrecognized.',
        );
        throw ToolExit(_exitIncorrectTargetDependency);
      }
      _targetVersion = version;
    }
  }

  @override
  Future<void> completeRun() async {
    _pubVersionFinder.httpClient.close();
  }

  @override
  Future<PackageResult> runForPackage(RepositoryPackage package) async {
    if (_targetPubPackage != null) {
      return _runForPubDependency(package, _targetPubPackage);
    }
    if (_targetAndroidDependency != null) {
      return _runForAndroidDependency(package);
    }

    // TODO(stuartmorgan): Add other dependency types here (e.g., maven).

    return PackageResult.fail();
  }

  /// Handles all of the updates for [package] when the target dependency is
  /// a pub dependency.
  Future<PackageResult> _runForPubDependency(
    RepositoryPackage package,
    String dependency,
  ) async {
    final _PubDependencyInfo? dependencyInfo = _getPubDependencyInfo(
      package,
      dependency,
    );
    if (dependencyInfo == null) {
      return PackageResult.skip('Does not depend on $dependency');
    } else if (!dependencyInfo.hosted) {
      return PackageResult.skip('$dependency in not a hosted dependency');
    }

    // Determine the target version constraint.
    final sectionKey = dependencyInfo.type == _PubDependencyType.dev
        ? 'dev_dependencies'
        : 'dependencies';
    final String versionString;
    final parsedConstraint = VersionConstraint.parse(_targetVersion);
    // If the provided string was a constraint, or if it's a specific
    // version but the package has a pinned dependency, use it as-is.
    if (dependencyInfo.pinned ||
        parsedConstraint is! VersionRange ||
        parsedConstraint.min != parsedConstraint.max) {
      versionString = _targetVersion;
    } else {
      // Otherwise, it's a specific version; treat it as '^version'.
      final Version minVersion = parsedConstraint.min!;
      versionString = '^$minVersion';
    }

    // Update pubspec.yaml with the new version.
    print('${indentation}Updating to "$versionString"');
    if (versionString == dependencyInfo.constraintString) {
      return PackageResult.skip('Already depends on $versionString');
    }
    final editablePubspec = YamlEditor(package.pubspecFile.readAsStringSync());
    editablePubspec.update(<String>[sectionKey, dependency], versionString);
    package.pubspecFile.writeAsStringSync(editablePubspec.toString());

    // Do any dependency-specific extra processing.
    if (dependency == 'pigeon') {
      if (!await _regeneratePigeonFiles(package)) {
        return PackageResult.fail(<String>['Failed to update pigeon files']);
      }
    } else if (dependency == 'mockito') {
      if (!await _regenerateMocks(package)) {
        return PackageResult.fail(<String>['Failed to update mocks']);
      }
    }
    // TODO(stuartmorgan): Add additional handling of known packages that
    // do file generation.

    return PackageResult.success();
  }

  /// Handles all of the updates for [package] when the target dependency is
  /// an Android dependency.
  Future<PackageResult> _runForAndroidDependency(
    RepositoryPackage package,
  ) async {
    if (_targetAndroidDependency == _AndroidDependencyType.compileSdk) {
      return _runForCompileSdkVersion(package);
    } else if (_targetAndroidDependency == _AndroidDependencyType.gradle ||
        _targetAndroidDependency ==
            _AndroidDependencyType.compileSdkForExamples ||
        _targetAndroidDependency ==
            _AndroidDependencyType.androidGradlePlugin ||
        _targetAndroidDependency == _AndroidDependencyType.kotlinGradlePlugin) {
      return _runForAndroidDependencyOnExamples(package);
    }

    return PackageResult.fail(<String>[
      'Target Android dependency $_androidDependency is unrecognized.',
    ]);
  }

  Future<PackageResult> _runForAndroidDependencyOnExamples(
    RepositoryPackage package,
  ) async {
    final Iterable<RepositoryPackage> packageExamples = package.getExamples();
    var updateRanForExamples = false;
    for (final example in packageExamples) {
      if (!example.platformDirectory(FlutterPlatform.android).existsSync()) {
        continue;
      }

      updateRanForExamples = true;
      final Directory androidDirectory = example.platformDirectory(
        FlutterPlatform.android,
      );
      final filesToUpdate = <File>[];
      final RegExp dependencyVersionPattern;
      final String newDependencyVersionEntry;

      if (_targetAndroidDependency == _AndroidDependencyType.gradle) {
        if (androidDirectory
            .childDirectory('gradle')
            .childDirectory('wrapper')
            .existsSync()) {
          filesToUpdate.add(
            androidDirectory
                .childDirectory('gradle')
                .childDirectory('wrapper')
                .childFile('gradle-wrapper.properties'),
          );
        }
        if (androidDirectory
            .childDirectory('app')
            .childDirectory('gradle')
            .childDirectory('wrapper')
            .existsSync()) {
          filesToUpdate.add(
            androidDirectory
                .childDirectory('app')
                .childDirectory('gradle')
                .childDirectory('wrapper')
                .childFile('gradle-wrapper.properties'),
          );
        }
        dependencyVersionPattern = RegExp(
          r'^\s*distributionUrl\s*=\s*.*\.zip',
          multiLine: true,
        );
        // TODO(camsim99): Validate current AGP version against target Gradle
        // version: https://github.com/flutter/flutter/issues/133887.
        newDependencyVersionEntry =
            'distributionUrl=https\\://services.gradle.org/distributions/gradle-$_targetVersion-all.zip';
      } else if (_targetAndroidDependency ==
          _AndroidDependencyType.compileSdkForExamples) {
        filesToUpdate.add(
          androidDirectory.childDirectory('app').childFile('build.gradle'),
        );
        dependencyVersionPattern = RegExp(
          r'(compileSdk|compileSdkVersion) (\d{1,2}|flutter.compileSdkVersion)',
        );
        newDependencyVersionEntry = 'compileSdk $_targetVersion';
      } else if (_targetAndroidDependency ==
          _AndroidDependencyType.androidGradlePlugin) {
        if (androidDirectory.childFile('settings.gradle').existsSync()) {
          filesToUpdate.add(androidDirectory.childFile('settings.gradle'));
        }
        dependencyVersionPattern = RegExp(
          r'^\s*id\s+"com\.android\.application"\s+version\s+"(\d{1,2}\.\d{1,2}(?:\.\d)?)"\s+apply\s+false\s*$',
          multiLine: true,
        );
        newDependencyVersionEntry =
            'id "com.android.application" version "$_targetVersion" apply false';
      } else if (_targetAndroidDependency ==
          _AndroidDependencyType.kotlinGradlePlugin) {
        if (androidDirectory.childFile('settings.gradle').existsSync()) {
          filesToUpdate.add(androidDirectory.childFile('settings.gradle'));
        }
        dependencyVersionPattern = RegExp(
          r'^\s*id\s+"org\.jetbrains\.kotlin\.android"\s+version\s+"(\d\.\d\.\d{1,2})"\s+apply\s+false\s*$',
          multiLine: true,
        );
        newDependencyVersionEntry =
            '    id "org.jetbrains.kotlin.android" version "$_targetVersion" apply false';
      } else {
        printError(
          'Target Android dependency $_targetAndroidDependency is unrecognized.',
        );
        throw ToolExit(_exitIncorrectTargetDependency);
      }

      for (final fileToUpdate in filesToUpdate) {
        final String oldFileToUpdateContents = fileToUpdate.readAsStringSync();

        if (!dependencyVersionPattern.hasMatch(oldFileToUpdateContents)) {
          return PackageResult.fail(<String>[
            'Unable to find a $_targetAndroidDependency version entry to update for ${example.displayName}.',
          ]);
        }

        print(
          '${indentation}Updating ${getRelativePosixPath(example.directory, from: package.directory)} to "$_targetVersion"',
        );
        final String newGradleWrapperPropertiesContents =
            oldFileToUpdateContents.replaceFirst(
              dependencyVersionPattern,
              newDependencyVersionEntry,
            );

        fileToUpdate.writeAsStringSync(newGradleWrapperPropertiesContents);
      }
    }
    return updateRanForExamples
        ? PackageResult.success()
        : PackageResult.skip('No example apps run on Android.');
  }

  Future<PackageResult> _runForCompileSdkVersion(
    RepositoryPackage package,
  ) async {
    if (!package.platformDirectory(FlutterPlatform.android).existsSync()) {
      return PackageResult.skip(
        'Package ${package.displayName} does not run on Android.',
      );
    } else if (package.isExample) {
      // We skip examples for this command.
      return PackageResult.skip(
        'Package ${package.displayName} is not a top-level package; run with "compileSdkForExamples" to update.',
      );
    }
    final File buildConfigurationFile = package
        .platformDirectory(FlutterPlatform.android)
        .childFile('build.gradle');
    final String buildConfigurationContents = buildConfigurationFile
        .readAsStringSync();
    final validCompileSdkVersion = RegExp(
      r'(compileSdk|compileSdkVersion) \d{1,2}',
    );

    if (!validCompileSdkVersion.hasMatch(buildConfigurationContents)) {
      return PackageResult.fail(<String>[
        'Unable to find a compileSdk version entry to update for ${package.displayName}.',
      ]);
    }
    print('${indentation}Updating ${package.directory} to "$_targetVersion"');
    final String newBuildConfigurationContents = buildConfigurationContents
        .replaceFirst(validCompileSdkVersion, 'compileSdk $_targetVersion');
    buildConfigurationFile.writeAsStringSync(newBuildConfigurationContents);

    return PackageResult.success();
  }

  /// Returns information about the current dependency of [package] on
  /// the package named [dependencyName], or null if there is no dependency.
  _PubDependencyInfo? _getPubDependencyInfo(
    RepositoryPackage package,
    String dependencyName,
  ) {
    final Pubspec pubspec = package.parsePubspec();

    Dependency? dependency;
    final _PubDependencyType type;
    if (pubspec.dependencies.containsKey(dependencyName)) {
      dependency = pubspec.dependencies[dependencyName];
      type = _PubDependencyType.normal;
    } else if (pubspec.devDependencies.containsKey(dependencyName)) {
      dependency = pubspec.devDependencies[dependencyName];
      type = _PubDependencyType.dev;
    } else {
      return null;
    }
    if (dependency != null && dependency is HostedDependency) {
      final VersionConstraint version = dependency.version;
      return _PubDependencyInfo(
        type,
        pinned: version is VersionRange && version.min == version.max,
        hosted: true,
        constraintString: version.toString(),
      );
    }
    return _PubDependencyInfo(type, pinned: false, hosted: false);
  }

  /// Returns all of the files in [package] that are, according to repository
  /// convention, Pigeon input files.
  Iterable<File> _getPigeonInputFiles(RepositoryPackage package) {
    // Repo convention is that the Pigeon input files are the Dart files in a
    // top-level "pigeons" directory.
    final Directory pigeonsDir = package.directory.childDirectory('pigeons');
    if (!pigeonsDir.existsSync()) {
      return <File>[];
    }
    return pigeonsDir.listSync().whereType<File>().where(
      (File file) => file.basename.endsWith('.dart'),
    );
  }

  /// Re-runs Pigeon generation for [package].
  ///
  /// This assumes that all output configuration is set in the input files, so
  /// no additional arguments are needed. If that assumption stops holding true,
  /// the tooling will need a way for packages to control the generation (e.g.,
  /// with a script file with a known name in the pigeons/ directory.)
  Future<bool> _regeneratePigeonFiles(RepositoryPackage package) async {
    final Iterable<File> inputs = _getPigeonInputFiles(package);
    if (inputs.isEmpty) {
      logWarning('No pigeon input files found.');
      return true;
    }

    print('${indentation}Running pub get...');
    if (!await runPubGet(
      package,
      processRunner,
      platform,
      streamOutput: false,
    )) {
      printError('${indentation}Fetching dependencies failed');
      return false;
    }

    print('${indentation}Updating Pigeon files...');
    for (final input in inputs) {
      final String relativePath = getRelativePosixPath(
        input,
        from: package.directory,
      );
      final io.ProcessResult pigeonResult = await processRunner.run(
        'dart',
        <String>['run', 'pigeon', '--input', relativePath],
        workingDir: package.directory,
      );
      if (pigeonResult.exitCode != 0) {
        printError(
          'dart run pigeon failed (${pigeonResult.exitCode}):\n'
          '${pigeonResult.stdout}\n${pigeonResult.stderr}\n',
        );
        return false;
      }
    }
    return true;
  }

  /// Re-runs Mockito mock generation for [package] if necessary.
  Future<bool> _regenerateMocks(RepositoryPackage package) async {
    final Pubspec pubspec = package.parsePubspec();
    if (!pubspec.devDependencies.keys.contains('build_runner')) {
      print(
        '${indentation}No build_runner dependency; skipping mock regeneration.',
      );
      return true;
    }

    print('${indentation}Running pub get...');
    if (!await runPubGet(
      package,
      processRunner,
      platform,
      streamOutput: false,
    )) {
      printError('${indentation}Fetching dependencies failed');
      return false;
    }

    print('${indentation}Updating mocks...');
    final io.ProcessResult buildRunnerResult = await processRunner.run(
      'dart',
      <String>['run', 'build_runner', 'build', '--delete-conflicting-outputs'],
      workingDir: package.directory,
    );
    if (buildRunnerResult.exitCode != 0) {
      printError(
        '"dart run build_runner build" failed (${buildRunnerResult.exitCode}):\n'
        '${buildRunnerResult.stdout}\n${buildRunnerResult.stderr}\n',
      );
      return false;
    }
    return true;
  }
}

class _PubDependencyInfo {
  const _PubDependencyInfo(
    this.type, {
    required this.pinned,
    required this.hosted,
    this.constraintString,
  });
  final _PubDependencyType type;
  final bool pinned;
  final bool hosted;
  final String? constraintString;
}

enum _PubDependencyType { normal, dev }

class _AndroidDependencyType {
  static const String gradle = 'gradle';
  static const String androidGradlePlugin = 'androidGradlePlugin';
  static const String kotlinGradlePlugin = 'kotlinGradlePlugin';
  static const String compileSdk = 'compileSdk';
  static const String compileSdkForExamples = 'compileSdkForExamples';
}
